Pro ASP.NET Core MVC2(第7版)翻译

第22章:视图组件

作者:Adam Freeman 翻译:陈广 日期:2018-10-7


我在本章中描述了视图组件,这些组件是 ASP.NET Core MVC 中的一个新功能,并取代了以前版本中的子 action 功能。视图组件是提供 action-style 逻辑以支持分部视图的类,这意味着复杂的内容可以嵌入到视图中,同时使得支持它的 C# 代码变得容易维护及进行单元测试。

表 22-1:视图组件来历

问题 回答
它们是什么? 视图组件是提供应用程序逻辑以支持分部视图或将 HTML 或 JSON 数据的小片段注入父视图的类。
它们有何用途? 没有视图组件,就很难以易于维护和单元测试的方式创建诸如购物篮或登录面板之类的嵌入式功能。
如何使用它们 视图组件通常是从ViewComponent类派生出来的,并使用@await Component.InvokeAsync表达式在父视图中应用。
是否有任何缺陷或限制? 没,视图组件是一个简单且可预测的功能。主要的缺陷是不使用它们,并试图在很难测试和维护的视图中包含应用程序逻辑。
有没有其他选择? 您可以将数据访问和处理逻辑直接放在分部视图中,但是结果很难处理,很难进行有效的测试。

表22-2为本章摘要

表 22-2:本章摘要

问题 解决方案 清单
为分部视图提供自己的逻辑和数据 使用视图组件 12
调用视图组件 在视图中使用@await Component.InvokeAsync表达式 13
简化对 context 数据和结果的访问 ViewComponent类继承 14-16
选择一个分部视图 使用View方法创建并返回一个ViewViewComponentResult对象 17-19
创建 HTML 片段 如果不希望对片段进行编码,则返回Content方法以创建一个ContentViewComponentResult对象或显示创建一个HtmlContentViewComponentResult对象 20、21
使用请求的详细信息生成结果 使用视图组件 context 数据 22
调用视图组件时提供 context 数据 InvokeAsync提供参数 23-25
创建一个异步视图组件 实现InvokeAsync方法并返回一个Task以产生所需的结果 26-29
创建一个混合 控制器/视图 组件 向控制器类应用ViewComponent特性 30-33

准备示例项目

本章我使用【ASP.NET Core Web 应用程序(.NET Core)】模板创建了一个名为 UsingViewComponents 的新的空项目。

创建模型和存储库

我需要两个不同的数据源来演示视图组件是如何工作的。应用程序的一部分将操作一组产品说明;为此,我创建了 Models 文件夹并添加了一个名为 Product.cs 的文件,用于定义清单22-1所示的类。

清单 22-1:Models 文件夹下的 Product.cs 文件的内容

namespace UsingViewComponents.Models
{
    public class Product
    {
        public string Name { get; set; }
        public decimal Price { get; set; }
    }
}

为产品对象创建一个存储库,我将一个名为 ProductRepository.cs 的文件添加到 Models 文件夹中,并定义了如清单22-2所示的接口和实现类。

清单 22-2:Models 文件夹下的 ProductRepository.cs 文件的内容

using System.Collections.Generic;

namespace UsingViewComponents.Models
{
    public interface IProductRepository
    {
        IEnumerable<Product> Products { get; }
        void AddProduct(Product newProduct);
    }
    public class MemoryProductRepository : IProductRepository
    {
        private List<Product> products = new List<Product> {
            new Product { Name = "Kayak", Price = 275M },
            new Product { Name = "Lifejacket", Price = 48.95M },
            new Product { Name = "Soccer ball", Price = 19.50M }
        };

        public IEnumerable<Product> Products => products;

        public void AddProduct(Product newProduct)
        {
            products.Add(newProduct);
        }
    }
}

IProductRepository接口定义了一组有限的存储库功能,MemoryProductRepository类使用内存中的List实现该接口。

应用程序的另一部分将对城市的描述进行操作。为此,我在 Models 文件夹中添加了一个名为 City.cs 的类文件,并使用它来定义清单22-3所示的类。

清单 22-3:Models 文件夹下的 City.cs 文件的内容

namespace UsingViewComponents.Models
{
    public class City
    {
        public string Name { get; set; }
        public string Country { get; set; }
        public int Population { get; set; }
    }
}

对于City对象的存储库,我创建了一个名为 CityRepository.cs 的类文件,并使用它定义了如清单22-4所示的接口和实现类。

清单 22-4:Models 文件夹下的 CityRepository.cs 文件的内容

using System.Collections.Generic;

namespace UsingViewComponents.Models
{
    public interface ICityRepository
    {
        IEnumerable<City> Cities { get; }
        void AddCity(City newCity);
    }
    public class MemoryCityRepository : ICityRepository
    {
        private List<City> cities = new List<City> {
            new City { Name = "London", Country = "UK", Population = 8539000},
            new City { Name = "New York", Country = "USA", Population = 8406000 },
            new City { Name = "San Jose", Country = "USA", Population = 998537 },
            new City { Name = "Paris", Country = "France", Population = 2244000 }
        };

        public IEnumerable<City> Cities => cities;

        public void AddCity(City newCity)
        {
            cities.Add(newCity);
        }
    }
}

ICityRepository接口提供了一组有限的存储库功能,MemoryCityRepository类使用内存中的列表实现该接口。

创建控制器和视图

我只需要一个控制器就可以开始了,所以我创建了 Controllers 文件夹,在 Controllers 文件夹中添加了一个名为 HomeController.cs 的文件,并使用它来定义清单22-5所示的类。

清单 22-5:Controllers 文件夹下的 HomeController.cs 文件的内容

using Microsoft.AspNetCore.Mvc;
using UsingViewComponents.Models;

namespace UsingViewComponents.Controllers
{
    public class HomeController : Controller
    {
        private IProductRepository repository;

        public HomeController(IProductRepository repo)
        {
            repository = repo;
        }

        public ViewResult Index() => View(repository.Products);

        public ViewResult Create() => View();

        [HttpPost]
        public IActionResult Create(Product newProduct)
        {
            repository.AddProduct(newProduct);
            return RedirectToAction("Index");
        }
    }
}

Home 控制器使用其构造函数声明对IProductRepository接口的依赖,当控制器用于处理请求时,服务提供者将解析该依赖关系。Index action 从存储库检索所有Product对象,并使用默认视图渲染它们。两个Create方法使用 Post/Redirect/Get 模式,使用客户端提供的表单数据向存储库添加新对象。

此示例的视图将共享一个公共布局。我创建了 Views/Shared 文件夹,并使用清单22-6所示的标记添加了一个名为 _Layout.cshtml 的文件。

清单 22-6:Views/Shared 文件夹下的 _Layout.cshtml 文件的内容

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>@ViewBag.Title</title>
    <link asp-href-include="lib/twitter-bootstrap/css/*.min.css" rel="stylesheet" />
</head>
<body class="m-1 p-1">
    <div class="bg-primary m-1 p-1">
        <div class="row text-white">
            <div class="col-7"><h1>Products</h1></div>
            <div class="col-5">
                <div class="bg-info text-center m-1 p-1">City Placeholder</div>
            </div>
        </div>
    </div>
    <div class="m-1 p-1">@RenderBody()</div>
</body>
</html>

布局定义了一个 header,其中包含了我将在本章后面使用城市存储库创建的内容的占位符。接下来,我创建了 Views/Home 文件夹,并使用清单22-7所示的标记添加了一个名为 Index.cshtml 的文件,其中列出了表中Product对象的详细信息。

清单 22-7:Views/Home 文件夹下的 Index.cshtml 文件的内容

@model IEnumerable<Product>
@{
    ViewData["Title"] = "Products";
    Layout = "_Layout";
}

<table class="table table-sm table-striped table-bordered">
    <thead>
        <tr><th>Name</th><th>Price</th></tr>
    </thead>
    <tbody>
        @foreach (var product in Model)
        {
            <tr>
                <td>@product.Name</td>
                <td>@product.Price</td>
            </tr>
        }
    </tbody>
</table>
<a asp-action="Create" class="btn btn-primary">Create</a>

Index .视图中的最后一个元素是一个a元素,我将其样式设置为一个按钮,并以Create action 为目标,这样用户就可以在存储库中创建一个新的Product对象。为了提供用户填写的表单,我在 Views/Home 文件夹中添加了 Create.cshtml 文件,并添加了清单22-8所示的标记。

清单 22-8:Views/Home 文件夹下的 Create.cshtml 文件的内容

@model Product
@{
    ViewData["Title"] = "Create Product";
    Layout = "_Layout";
}

<form method="post" asp-action="Create">
    <div class="form-group">
        <label asp-for="Name">Name:</label>
        <input class="form-control" asp-for="Name" />
    </div>
    <div class="form-group">
        <label asp-for="Price">Price:</label>
        <input class="form-control" asp-for="Price" />
    </div>
    <button type="submit" class="btn btn-primary">Create</button>
    <a class="btn btn-secondary" asp-action="Index">Cancel</a>
</form>

视图使用内置标签助手,我在 Views 文件夹中创建 _ViewImports.cshtml 文件并添加清单22-9中所示的表达式,从而使 Models 文件夹中的类在没有命名空间的情况下可用。

清单 22-9:Views 文件夹下的 _ViewImports.cshtml 文件的内容

@using UsingViewComponents.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

视图还依赖 Bootstrap CSS 包来对内容样式化。我在 UsingViewComponents 项目中单击鼠标右键,在弹出菜单中选择【添加】➤【添加客户端库】,并将 twitter-bootstrap 添加至项目中。最终生成的 libman.json 配置文件代码清单18-7所示:

清单 22-10:ApiControllers 文件夹下的 libman.json 文件的内容

{
  "version": "1.0",
  "defaultProvider": "cdnjs",
  "libraries": [
    {
      "library": "twitter-bootstrap@4.1.3",
      "destination": "wwwroot/lib/twitter-bootstrap/"
    }
  ]
}

配置应用程序

最后的准备步骤是配置应用程序,如清单22-11所示。除了设置 MVC 服务和中间件之外,我还为这两个数据存储库创建了单例服务。

清单 22-11:UsingViewComponents 文件夹下的 Startup.cs 文件的内容

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using UsingViewComponents.Models;

namespace UsingViewComponents
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddSingleton<IProductRepository, MemoryProductRepository>();
            services.AddSingleton<ICityRepository, MemoryCityRepository>();
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseStatusCodePages();
            app.UseDeveloperExceptionPage();
            app.UseStaticFiles();
            app.UseMvcWithDefaultRoute();
        }
    }
}

如果运行应用程序,您将在产品存储库中看到Product对象的列表。您可以通过单击【Create】按钮添加新产品,填充表单并将其提交给服务器,然后服务器将浏览器重定向回列表,如图22-1所示。由于应用程序中的视图共享公共布局,因此在此过程中显示了一个城市数据占位符。

图22-1 运行示例应用程序

理解视图组件

应用程序通常需要在与应用程序的主要用途无关的视图中嵌入内容。常见的示例包括站点导航工具和身份验证面板,它们允许用户登录而无需访问单独的页面。

所有这些示例的共同线程是,显示嵌入内容所需的数据不是从 action 传递到视图的模型数据的一部分。正是由于这个原因,我在示例应用程序中创建了两个存储库:我将显示使用 City 存储库生成的一些内容,这在 action 中从Product存储库接收数据的视图中并不容易完成。

在第21章中,我描述了如何使用分部视图创建视图所需的可重用标记,从而避免了在应用程序的多个地方重复相同内容。分部视图是一种有用的特性,但它们只包含 HTML 和 Razor 指令的片段,它们所操作的数据是从父视图接收的。如果需要显示不同的数据,则会遇到问题。您可以直接从分部视图访问所需的数据,但这打破了作为 MVC 模式基础的关注点分离,导致数据检索和处理逻辑被放置在无法进行单元测试的视图文件中。或者,您可以扩展应用程序使用的视图模型,以便它包含所需的数据,但这意味着您必须更改每个操作方法,并且很难隔离 action 方法的功能以进行有效的测试。

这就是视图组件应该出现的地方。视图组件是一个 C# 类,它提供了分部视图及其所需要的数据,它独立于父视图和渲染它的 action。在这方面,视图组件可以被看作是一个专门的 action,但它只用于提供携带数据的分部视图;无法接收 HTTP 请求,并且它提供的内容将始终包含在父视图中。

创建一个视图组件

视图组件可以通过三种不同的方式创建:定义 POCO 视图组件、从ViewComponent基类派生以及使用ViewComponent特性。我在后面的章节中描述了 POCO 和基类技术,并在本章后面的《创建混合 控制器/视图组件 类》部分中解释了该特性的使用。

创建 POCO 视图组件

POCO 视图组件是一个类,它提供视图组件功能,而不依赖于任何 MVC APIs。与 POCO 控制器一样,这种视图组件很难使用,但可以帮助理解它们是如何工作的。POCO 视图组件是名称以 ViewComponent 结尾并定义了Invoke方法的任何类。可以在应用程序中的任何地方定义视图组件类,但约定是将它们组合在一个名为 Components 的文件夹中,该文件夹位于项目的根级。我创建了这个文件夹,并添加了一个名为 PocoViewComponent.cs 的类文件,用于定义清单22-12所示的类。

清单 22-12:Components 文件夹下的 PocoViewComponent.cs 文件的内容

using System.Linq;
using UsingViewComponents.Models;

namespace UsingViewComponents.ViewComponents
{
    public class PocoViewComponent
    {
        private ICityRepository repository;

        public PocoViewComponent(ICityRepository repo)
        {
            repository = repo;
        }

        public string Invoke()
        {
            return $"{repository.Cities.Count()} cities, "
                + $"{repository.Cities.Sum(c => c.Population)} people";
        }
    }
}

视图组件可以利用依赖注入来接收它们所需的服务。在本例中,POCO 视图组件声明了一个依赖于ICityRepository的接口,然后在Invoke方法中使用该接口创建一个字符串,该字符串描述城市数量和人口总数。

若要使用视图组件,必须使用 Razor @await Component.Invoke表达式。视图组件是通过所提供的类的名称(去除ViewComponent后缀)来进行选择的。在清单22-13中,我删除了共享布局中的占位符,转而应用了 POCO 视图组件。

清单 22-13:Views/Shared 文件夹下的 _Layout.cshtml 文件,应用视图组件

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>@ViewBag.Title</title>
    <link asp-href-include="lib/twitter-bootstrap/css/*.min.css" rel="stylesheet" />
</head>
<body class="m-1 p-1">
    <div class="bg-primary m-1 p-1">
        <div class="row text-white">
            <div class="col-7"><h1>Products</h1></div>
            <div class="col-5">
                @await Component.InvokeAsync("Poco")
            </div>
        </div>
    </div>
    <div class="m-1 p-1">@RenderBody()</div>
</body>
</html>

为了应用视图组件,我将Poco指定为Invoke方法的参数。当视图使用布局时,它定位PocoViewComponent类,调用其Invoke方法,并将结果插入到父视图的输出中,如图22-2所示。

图22-2 使用简单的视图组件

这是一个简单的例子,但它说明了视图组件的一些重要特性。首先,PocoViewComponent类能够访问它所需的数据,而不依赖于处理 HTTP 请求或其父视图的 action。其实定义需要包含的逻辑以及在 C# 类中处理 City 摘要意味着易于进行单元测试(有关示例,请参阅本章后面的《单元测试视图组件》侧栏)。第三,应用程序没有扭曲,试图将City对象包括在着眼于Product对象的视图模型中。简而言之,视图组件是一个可重用功能的自包含块,可以在整个应用程序中应用,可以单独开发和测试。

警告:在视图中应用视图组件时,必须包括await关键字。如果只调用@Component.Invoke,则不会看到错误,但是将显示Task的字符串表示形式,类似于:System.Threading.Tasks.Task'1[Microsoft.AspNetCore.Html.IHtmlContent]

派生自 ViewComponent 基类

POCO 视图组件的功能是有限的,除非它们利用了 MVC API,这是可能的,但需要付出更多的努力,而更常用的方法是从ViewComponent类派生。ViewComponent类定义在Microsoft.AspNetCore.Mvc命名空间中,它提供了对 context 数据的方便访问,并更容易使生成结果。清单22-14显示了 CitySummary.cs 文件的内容,我将其添加到 Components 文件夹中。

清单 22-14:Components 文件夹下的 CitySummary.cs 文件的内容

using System.Linq;
using Microsoft.AspNetCore.Mvc;
using UsingViewComponents.Models;

namespace UsingViewComponents.Components
{
    public class CitySummary : ViewComponent
    {
        private ICityRepository repository;
        public CitySummary(ICityRepository repo)
        {
            repository = repo;
        }
        public string Invoke()
        {
            return $"{repository.Cities.Count()} cities, "
                + $"{repository.Cities.Sum(c => c.Population)} people";
        }
    }
}

从基类派生时,不需要在类名中包含 ViewComponent。除了使用基类之外,此视图组件在功能上与 POCO 相同。在接下来的部分中,我将向您展示如何通过基类提供的便捷特性来使用不同的视图组件特性。

提示:注意,清单22-14中没有重写Invoke方法。ViewComponent类没有提供Invoke方法的默认实现,必须显式定义该方法。

为了准备演示视图组件特性,我更改了共享布局中使用的组件,如清单22-15所示。我没有使用字面量字符串来指定视图组件名称,而是使用了nameof(如第4章所述),这减少了错误键入类名的可能性。

清单 22-15:Views/Shared 文件夹下的 _Layout.cshtml 文件,更改视图组件

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>@ViewBag.Title</title>
    <link asp-href-include="lib/twitter-bootstrap/css/*.min.css" rel="stylesheet" />
</head>
<body class="m-1 p-1">
    <div class="bg-primary m-1 p-1">
        <div class="row text-white">
            <div class="col-7"><h1>Products</h1></div>
            <div class="col-5">
                @await Component.InvokeAsync(nameof(CitySummary))
            </div>
        </div>
    </div>
    <div class="m-1 p-1">@RenderBody()</div>
</body>
</html>

为了在不使用命名空间的情况下引用nameof表达式中的CitySummary类,我对视图导入文件进行了清单22-16所示的更改。

清单 22-16:Views 文件夹下的 _ViewImports.cshtml 文件,添加命名空间

@using UsingViewComponents.Models
@using UsingViewComponents.Components
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

理解视图组件结果

在父视图中插入简单字符串值的功能并不特别有用,但幸运的是,视图组件能够提供更多功能。更复杂的效果可以通过让Invoke方法返回实现IViewComponentResult接口的对象来实现。三个内置类实现了IViewComponentResult接口,它们在表22-3中进行了描述,以及ViewComponent基类提供的创建它们的便捷方法。我在下面的章节中描述了每种结果类型的使用情况。

注意:如果您正在使用 POCO 视图组件,可以直接创建这些类的实例,尽管它们很难使用,因为它们具有由ViewComponent类提供的便捷方法提供的复杂构造函数参数。

表 22-3:内置 IViewComponentResult 实现类

名称 描述
ViewViewComponentResult 此类用于指定具有可选视图模型数据的 Razor 视图。此类的实例是使用View方法创建的。
ContentViewComponentResult 这个类用于指定一个文本结果,它将被安全编码以包含在 HTML 文档中。此类的实例是使用Content方法创建的。
HtmlContentViewComponentResult 此类用于指定将包含在 HTML 文档中的 HTML 片段,而无需进一步编码。没有ViewComponent方法来创建这种结果类型。

有两种结果类型的特殊处理。如果一个视图组件返回一个字符串,那么它将用于创建一个ContentViewComponentResult对象,这正是我在前面的示例中所依赖的。如果一个视图组件返回一个IHtmlContent对象,那么它将用于创建一个HtmlContentViewComponentResult对象。

返回分部视图

最有用的响应是命名笨拙的ViewViewComponentResult对象,它告诉 Razor 渲染分部视图,并将结果包含在父视图中。ViewComponent基类提供了用于创建ViewViewComponentResult对象的视图方法,该方法有四个版本,如表22-4所述。

表 22-4:ViewComponent.View 方法

名称 描述
View() 使用此方法选择视图组件的默认视图,而不提供视图模型。
View(model) 使用该方法选择默认视图,并使用指定的对象作为视图模型。
View(viewName) 使用此方法可选择指定的视图,而不提供视图模型。
View(viewName, model) 使用此方法选择指定的视图并使用指定的对象作为视图模型。

这些方法对应于Controller基类提供的方法,并且使用的方式非常相同。我在 Models 文件夹中添加了一个名为 CityViewModel.cs 的类文件,并使用它来定义如清单22-17所示的视图模型。

清单 22-17:Models 文件夹下的 CityViewModel.cs 文件的内容

namespace UsingViewComponents.Models
{
    public class CityViewModel
    {
        public int Cities { get; set; }
        public int Population { get; set; }
    }
}

在清单22-18中,我修改了CitySummary视图组件的Invoke方法,以便它使用View方法选择分部视图,并使用CityViewModel对象提供视图数据。

清单 22-18:Components 文件夹下的 CitySummary.cs 文件,选择分部视图

using System.Linq;
using Microsoft.AspNetCore.Mvc;
using UsingViewComponents.Models;

namespace UsingViewComponents.Components
{
    public class CitySummary : ViewComponent
    {
        private ICityRepository repository;
        public CitySummary(ICityRepository repo)
        {
            repository = repo;
        }

        public IViewComponentResult Invoke()
        {
            return View(new CityViewModel
            {
                Cities = repository.Cities.Count(),
                Population = repository.Cities.Sum(c => c.Population)
            });
        }
    }
}

在视图组件中选择一个分部视图类似于在一个控制器中选择一个视图,但是有两个重要的区别:Razor 在不同的位置查找视图,如果没有指定视图,则使用不同的默认视图名称。

因为我还没有为视图组件创建一个分部视图,所以当您运行应用程序,显示 Razor 正在查找的文件时,您将看到一条错误消息。

  • /Views/Home/Components/CitySummary/Default.cshtml
  • /Views/Shared/Components/CitySummary/Default.cshtml

如果没有指定名称,则 Razor 将查找一个名为 Default.cshtml 的文件。Razor 在两个位置寻找分部视图。第一个位置考虑到处理 HTTP 请求的控制器的名称,该名称允许每个控制器都有自己的自定义视图。第二个位置在所有控制器之间共享。

提示:注意,共享分部视图仍然由视图组件来区分,这意味着视图组件不共享分部视图。在调用View方法时,可以通过在视图名称中包含路径来覆盖此行为,这样调用视图("Views/Shared/Components/Common/Default. html" )将覆盖正常的搜索位置。

为了完成这个示例,我创建了 Views/Home/Components/CitySummary 文件夹,并向它添加了一个名为 Default.cshtml 的新文件,向其中添加了清单22-19所示的标记。

清单 22-19:Views/Home/Components/CitySummary 文件夹下的 Default.cshtml 文件的内容

@model CityViewModel

<table class="table table-sm table-bordered">
    <tr>
        <td>Cities:</td>
        <td class="text-right">
            @Model.Cities
        </td>
    </tr>
    <tr>
        <td>Population:</td>
        <td class="text-right">
            @Model.Population.ToString("#,###")
        </td>
    </tr>
</table>

视图组件的分部视图以与控制器相同的方式工作。在本例中,我创建了一个强类型视图,它需要一个CityViewModel对象,并在一个表中显示其城市和人口值,如图22-3所示。

图22-3 使用视图组件渲染视图

返回 HTML 片段

ContentViewComponentResult类用于在不使用视图的情况下将 HTML 的片段包含在父视图中。ContentViewComponentResult类的实例是使用从ViewComponent基类继承的Content方法创建的,它接受字符串值。清单22-20演示了Content方法的使用方法。除了Content方法之外,Invoke方法还可以返回一个字符串,MVC 将自动将其转换为ContentViewComponentResult

清单 22-20:Components 文件夹下的 CitySummary.cs 文件,使用 Content 方法

using System.Linq;
using Microsoft.AspNetCore.Mvc;
using UsingViewComponents.Models;

namespace UsingViewComponents.Components
{
    public class CitySummary : ViewComponent
    {
        private ICityRepository repository;
        public CitySummary(ICityRepository repo)
        {
            repository = repo;
        }

        public IViewComponentResult Invoke()
        {
            return Content("This is a <h3><i>string</i></h3>");
        }
    }
}

对由Content方法接收的字符串进行编码,使其安全地包含在 HTML 文档中。在处理用户或外部系统提供的内容时,这一点特别重要,因为它阻止将 JavaScript 内容嵌入到应用程序生成的 HTML 中。在本例中,我传递给Content方法的字符串包含一些基本的 HTML 标记,如果运行应用程序,您将看到它们已被安全编码,如图22-4所示。

图22-4 使用视图组件返回编码的 HTML 片段

如果查看视图组件生成的 HTML,您将看到尖括号已经被替换,这样浏览器就不会将内容解释为 HTML 元素,如下所示:

...
<div class="col-5">This is a &lt;h3&gt;&lt;i&gt;string&lt;/i&gt;&lt;/h3&gt;</div>
...

如果您信任内容源并希望将其解释为 HTML,则不需要对其进行编码。Content方法总是对其参数进行编码,因此必须直接创建HtmlContentViewComponentResult对象,并为其构造函数提供一个HtmlString对象,它表示一个您知道可以安全显示的字符串,因为它来自您信任的源,或者因为您确信它已经被编码了,如清单22-21所示。

清单 22-21:Components 文件夹下的 CitySummary.cs 文件,返回信任的 HTML 片段

using System.Linq;
using Microsoft.AspNetCore.Mvc;
using UsingViewComponents.Models;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Microsoft.AspNetCore.Html;

namespace UsingViewComponents.Components
{
    public class CitySummary : ViewComponent
    {
        private ICityRepository repository;
        public CitySummary(ICityRepository repo)
        {
            repository = repo;
        }

        public IViewComponentResult Invoke()
        {
            return new HtmlContentViewComponentResult(
                new HtmlString("This is a <h3><i>string</i></h3>"));
        }
    }
}

这种技术应该谨慎使用,并且只适用于不能被篡改并执行自己编码的内容来源。如果您运行应用程序,将看到在父视图中没有修改就将尖括号包括在内,这允许浏览器将视图组件的输出解释为 HTML 元素,如图22-5所示。

图22-5 使用视图组件返回未编码的 HTML 片段

获取 Context 数据

有关当前请求和父视图的详细信息是通过ViewComponentContext类的属性提供给视图组件的;表22-5描述了它提供的最有用的属性。

表 22-5:ViewComponentContext属性

名称 描述
Arguments 此属性返回视图提供的参数的字典,也可以通过Invoke方法接收这些参数。
HtmlEncoder 此属性返回一个HtmlEncoder对象,可用于安全地编码 HTML 片段。
ViewComponentDescriptor 此属性返回ViewComponentDescriptor,以提供视图组件的描述。
ViewContext 此属性从父视图返回ViewContext对象。有关这个类提供的特性的详细信息,请参阅第21章。
ViewData 此属性返回ViewDataDictionary,它提供对视图组件提供的视图数据的访问。

ViewComponent基类提供了一组便捷的属性,可以更容易地访问特定的 context 信息,如表22-6所述。

表 22-6:ViewComponent 便捷属性

名称 描述
ViewComponentContext 此属性返回ViewComponentContext对象
HttpContext 此属性返回一个HttpContext对象,描述当前请求和正在准备的响应。
Request 此属性返回描述当前 HTTP 请求的HttpRequest对象。
User 此属性返回描述当前用户的IPrincipal对象,如第28章所述。
RouteData 此属性返回一个RouteData对象,描述当前请求的路由数据,如第15章所述。
ViewBag 此属性返回动态 view bag 对象,可用于在视图组件和视图之间传递数据。
ModelState 此属性返回一个ModelStateDictionary,它提供模型绑定过程的详细信息,如第26章所述。
ViewContext 此属性返回提供给父视图的ViewContext对象,如第21章所述。
ViewData 此属性返回ViewDataDictionary,它提供对视图组件提供的视图数据的访问。
Url 该属性返回一个IUrlHelper对象,可用于生成 URL,如第15章所述。

context 数据可以任何方式使用,以帮助视图组件完成其工作,包括更改数据的选择方式或渲染不同的内容或视图。在清单22-22中,我使用路由数据缩小了City对象的选择范围。

清单 22-22:Components 文件夹下的 CitySummary.cs 文件,使用 Context 数据

using System.Linq;
using Microsoft.AspNetCore.Mvc;
using UsingViewComponents.Models;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Microsoft.AspNetCore.Html;

namespace UsingViewComponents.Components
{
    public class CitySummary : ViewComponent
    {
        private ICityRepository repository;
        public CitySummary(ICityRepository repo)
        {
            repository = repo;
        }

        public IViewComponentResult Invoke()
        {
            string target = RouteData.Values["id"] as string;
            var cities = repository.Cities
                .Where(city => target == null ||
                    string.Compare(city.Country, target, true) == 0);
            return View(new CityViewModel
            {
                Cities = cities.Count(),
                Population = cities.Sum(c => c.Population)
            });
        }
    }
}

浏览器使用路由中的id段来指定国家,这是通过 LINQ 筛选存储库中的对象实现的。如果启动应用程序并请求默认的 URL,则会显示所有城市。您可以通过请求一个URL(如 /Home/Index/USA)来缩小选择范围,这将缩小对美国城市的选择范围,如图22-6所示。

图22-6 在视图组件中使用 context 数据

使用参数从父视图提供 Context

父视图可以提供额外的 context 数据作为@await Component.Invoke expression表达式的参数。此功能可用于提供来自父视图模型的数据,或指导视图组件应该生成的内容类型。为了演示这个功能,我在 Views/Home/Component/CitySummary 文件夹中创建了一个名为 CityList.cshtml 的视图文件,并添加了清单22-23所示的标记。

清单 22-23:Views/Home/Component/CitySummary 文件夹下的 CityList.cshtml 文件的内容

@model IEnumerable<City>

<table class="table table-sm table-bordered">
    @foreach (var city in Model)
    {
        <tr>
            <td>@city.Name</td>
            <td class="text-right">
                @city.Population.ToString("#,###")
            </td>
        </tr>
    }
    <tr>
        <th>Total:</th>
        <td class="text-right">
            @Model.Sum(p => p.Population).ToString("#,###")
        </td>
    </tr>
</table>

添加第二个视图允许视图组件在它们之间进行选择,这是基于添加到Invoke方法的参数所做的,如清单22-24所示。

清单 22-24:Components 文件夹下的 CitySummary.cs 文件,选择视图

using System.Linq;
using Microsoft.AspNetCore.Mvc;
using UsingViewComponents.Models;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Microsoft.AspNetCore.Html;

namespace UsingViewComponents.Components
{
    public class CitySummary : ViewComponent
    {
        private ICityRepository repository;
        public CitySummary(ICityRepository repo)
        {
            repository = repo;
        }

        public IViewComponentResult Invoke(bool showList)
        {
            if (showList)
            {
                return View("CityList", repository.Cities);
            }
            else
            {
                return View(new CityViewModel
                {
                    Cities = repository.Cities.Count(),
                    Population = repository.Cities.Sum(c => c.Population)
                });
            }
        }
    }
}

如果Invoke方法的showList参数为true,则视图组件选择CityList并将存储库中的所有City对象作为视图模型传递。如果showList参数为false,则将选择默认视图,并为视图模型提供一个CitySummary对象。

最后一步是在父视图中应用视图组件时提供 context 数据,这是通过向Invoke方法传递一个匿名对象来完成的,如清单22-25所示。

清单 22-25:Views/Shared 文件夹下的 _Layout.cshtml 文件,提供 Context 数据

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>@ViewBag.Title</title>
    <link asp-href-include="lib/twitter-bootstrap/css/*.min.css" rel="stylesheet" />
</head>
<body class="m-1 p-1">
    <div class="bg-primary m-1 p-1">
        <div class="row text-white">
            <div class="col-7"><h1>Products</h1></div>
            <div class="col-5">
                @await Component.InvokeAsync("CitySummary", new { showList = true })
            </div>
        </div>
    </div>
    <div class="m-1 p-1">@RenderBody()</div>
</body>
</html>

如果运行应用程序,视图组件将接收父视图指定的值并相应地响应,如图22-7所示。

图22-7 向视图组件提供 context 数据

单元测试视图组件

视图组件遵循通用的 MVC 方法,将选择和处理模型数据的逻辑从格式化和呈现它的视图标记中分离出来,这使得执行单元测试变得很容易。下面是示例应用程序中城市摘要的一个示例单元测试:

using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Moq;
using UsingViewComponents.Models;
using UsingViewComponents.Components;
using Xunit;

namespace UsingViewComponents.Tests {

    public class SummaryViewComponentTests {

        [Fact]
        public void TestSummary() {
            // Arrange
            var mockRepository = new Mock<ICityRepository>();
            mockRepository.SetupGet(m => m.Cities).Returns(new List<City> {
                new City { Population = 100 },
                new City { Population = 20000 },
                new City { Population = 1000000 },
                new City { Population = 500000 }
            });
            var viewComponent
                = new CitySummary(mockRepository.Object);

            // Act
            ViewViewComponentResult result
                = viewComponent.Invoke(false) as ViewViewComponentResult;

            // Assert
            Assert.IsType(typeof(CityViewModel), result.ViewData.Model);
            Assert.Equal(4, ((CityViewModel)result.ViewData.Model).Cities);
            Assert.Equal(1520100,
                ((CityViewModel)result.ViewData.Model).Population);
        }
    }
}

为了安排测试,我创建了一个伪存储库,并将它传递给CitySummary类的构造函数,以创建视图组件的一个新实例。对于测试的 act 部分,我调用了Invoke方法,它为我提供了一个结果对象。视图组件选择一个 Razor 视图,因此我将结果转换为ViewViewComponentResult,并通过它提供的ViewData.Model属性访问视图模型对象。对于测试的 assert 部分,我检查视图模型数据的类型及其包含的值。


创建异步视图组件

到目前为止,本章中的所有示例都是同步视图组件,可以识别这些组件,因为它们定义了Invoke方法。如果视图组件依赖于异步 API,那么可以通过定义返回TaskInvokeAsync方法来创建异步视图组件。当 Razor 从InvokeAsync方法接收到Task时,它将等待它完成,然后将结果插入主视图中。为了准备这个示例,右键单击【解决方案资源管理器】中的 UsingViewComponents 项目项,选择【编辑 UsingViewComponents.csproj】,并进行清单22-26所示的更改,向项目添加一个新包。

清单 22-26:UsingViewComponents.csproj 文件,添加一个程序包

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp2.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <Folder Include="wwwroot\" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.App" />
    <PackageReference Include="System.Net.Http" Version="4.3.3" />
  </ItemGroup>

</Project>

System.Net.Http包提供了一个用于发出异步 HTTP 请求的 API,我将使用该 API 查询 Apress.com 网站。清单22-27显示了名为 PageSize.cs 的类文件的内容,我将其添加到 Components 文件夹中,用于创建异步视图组件。

清单 22-27:Components 文件夹下的 PageSize.cs 文件的内容

using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;

namespace UsingViewComponents.Components
{
    public class PageSize : ViewComponent
    {
        public async Task<IViewComponentResult> InvokeAsync()
        {
            HttpClient client = new HttpClient();
            HttpResponseMessage response
                = await client.GetAsync("http://apress.com");
            return View(response.Content.Headers.ContentLength);
        }
    }
}

InvokeAsync方法通过第4章中描述的asyncawait关键字来使用HttpClient类提供的异步 API,并通过向 Apress.com 发送 GET 请求来获取返回的内容的长度。长度传递给View方法,该方法选择与视图组件关联的默认分部视图。

为了创建视图,我将 Views/Shared/Components/PageSize 文件夹添加到项目中,并添加了一个名为 Default.cshtml 的视图文件,内容如清单22-28所示。

清单 22-28:Views/Shared/Components/PageSize 文件夹下的 Default.cshtml 文件的内容

@model long
<div class="m-1 p-1 bg-info text-white">Page size: @Model</div>

最后一步是使用组件,这是在 _Layout.cshtml 文件中完成的,如清单22-29所示。

清单 22-29:Views/Shared 文件夹下的 _Layout.cshtml 文件,使用异步组件

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>@ViewBag.Title</title>
    <link asp-href-include="lib/twitter-bootstrap/css/*.min.css" rel="stylesheet" />
</head>
<body class="m-1 p-1">
    <div class="bg-primary m-1 p-1">
        <div class="row text-white">
            <div class="col-7"><h1>Products</h1></div>
            <div class="col-5">
                @await Component.InvokeAsync("CitySummary", new { showList = true })
            </div>
        </div>
    </div>
    <div class="m-1 p-1">@RenderBody()</div>
    @await Component.InvokeAsync("PageSize")
</body>
</html>

如果启动应用程序,您将看到浏览器提供的内容中添加了一个新内容,如图22-8所示。运行该示例时显示的数字可能会更改,因为 Apress 经常更新其网站。

图22-8 创建一个异步视图组件

创建混合 控制器/视图组件 类

视图组件通常提供控制器深入处理的功能的摘要或快照。例如,对于购物篮摘要的视图组件,通常会有一个指向控制器的链接,该链接提供购物篮中产品的详细列表,并可用于签出和完成购买。

在这种情况下,您可以创建一个类,它是一个控制器和一个视图组件,它允许将相关功能分组在一起,并减少代码重复。为了演示,我向 Controllers 文件夹中添加了一个名为 CityController.cs 的类文件,并使用它定义了如清单22-30所示的控制器。

清单 22-30:Controllers 文件夹下的 CityController.cs 文件的内容

using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using UsingViewComponents.Models;

namespace UsingViewComponents.Controllers
{
    [ViewComponent(Name = "ComboComponent")]
    public class CityController : Controller
    {
        private ICityRepository repository;

        public CityController(ICityRepository repo)
        {
            repository = repo;
        }

        public ViewResult Create() => View();

        [HttpPost]
        public IActionResult Create(City newCity)
        {
            repository.AddCity(newCity);
            return RedirectToAction("Index", "Home");
        }

        public IViewComponentResult Invoke() => new ViewViewComponentResult()
        {
            ViewData = new ViewDataDictionary<IEnumerable<City>>(ViewData,
                repository.Cities)
        };
    }
}

ViewComponent特性应用于不从ViewComponent基类继承且其名称不以 ViewComponent 结尾的类,这意味着正常的发现过程通常不会将该类归类为视图组件。Name属性设置类名称,在父视图中使用@Component.Invoke表达式应用类时可以引用该类。在本例中,我使用Name属性将类的视图组件部分的名称设置为ComboComponent。此名称将用于调用视图组件,并用于定位其视图。

由于混合类不继承ViewComponent基类,因此它们无法访问创建IViewComponentResult对象的便捷方法,这意味着我必须直接创建ViewViewComponentResult对象,就像 POCO 视图组件中所需的那样。

创建混合视图

混合类需要两组视图:类用作控制器时渲染的视图,以及类用作视图组件时渲染的视图。首先,我创建了 Views/City 文件夹,并添加了一个名为 Create.cshtml 的视图文件,其内容如清单22-31所示。

清单 22-31:Views/City 文件夹下的 Create.cshtml 文件的内容

@model City
@{
    ViewData["Title"] = "Create City";
    Layout = "_Layout";
}

<form method="post" asp-action="Create">
    <div class="form-group">
        <label asp-for="Name">Name:</label>
        <input class="form-control" asp-for="Name" />
    </div>
    <div class="form-group">
        <label asp-for="Country">Country:</label>
        <input class="form-control" asp-for="Country" />
    </div>
    <div class="form-group">
        <label asp-for="Population">Population:</label>
        <input class="form-control" asp-for="Population" />
    </div>
    <button type="submit" class="btn btn-primary">Create</button>
    <a class="btn btn-secondary" asp-controller="Home"
       asp-action="Index">
        Cancel
    </a>
</form>

此视图为创建新的City对象提供了一个简单的表单。【Create】按钮向 City 控制器上的Create action 发送 POST 请求,而【Cancel】按钮向 Home 控制器上的Index action 发送 GET 请求。

接下来,我创建了 Views/Shared/Components/ComboComponent 文件夹,并添加了一个名为 Default.cshtml 的视图文件,内容如清单22-32所示。我将分部视图放置在 Views/Shared 文件夹中,因为控制器对应的视图将使用视图组件,其名称将包含在用于定位视图的路径中。

清单 22-32:Views/Shared/Components/ComboComponent 文件夹下的 Default.cshtml 文件

@model IEnumerable<City>

<table class="table table-sm table-bordered">
    <tr>
        <td>Biggest City:</td>
        <td>
            @Model.OrderByDescending(c => c.Population).First().Name
        </td>
    </tr>
</table>
<a class="btn btn-sm btn-info" asp-controller="City" asp-action="Create">
    Create City
</a>

此分部视图接收一个City对象序列,它使用 LINQ 对其进行排序,以选择具有最大Population值的对象。还有一个锚元素,格式化为一个按钮,它以 City 控制器上的Create action 为目标。

提示:注意,我显式地为清单22-32中的a元素指定了 City 控制器。URL 是使用父视图提供的 context 数据生成的,这意味着默认控制器是处理请求的控制器,而不是视图组件。如果我省略了asp-controller特性,那么链接就会针对 Home 控制器上的Create方法。

应用混合类

最后一步是使用ViewComponent特性指定的名称将混合类作为共享布局中的视图组件应用,如清单22-33所示。

清单 22-33:Views/Shared 文件夹下的 _Layout.cshtml 文件,应用混合类

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>@ViewBag.Title</title>
    <link asp-href-include="lib/twitter-bootstrap/css/*.min.css" rel="stylesheet" />
</head>
<body class="m-1 p-1">
    <div class="bg-primary m-1 p-1">
        <div class="row text-white">
            <div class="col-7"><h1>Products</h1></div>
            <div class="col-5">
                @await Component.InvokeAsync("ComboComponent")
            </div>
        </div>
    </div>
    <div class="m-1 p-1">@RenderBody()</div>
    @await Component.InvokeAsync("PageSize")
</body>
</html>

结果是由自己的集成控制器备份的视图组件(或者,如果您愿意的话,是一个具有自己的集成视图组件的控制器)。如果您运行该应用程序,您将看到 London 被列为人口最多的城市。单击【Create City】按钮,您将看到一个表单,它允许您向应用程序添加一个新的城市。填写并提交表单,控制器将接收数据,更新存储库,并将浏览器重定向到应用程序的默认 URL。如果您在存储库中添加了一个人口超过其他城市的City,那么视图组件的输出将发生变化,如图22-9所示。

图22-9 使用混合 控制器/视图组件 类

摘要

我在本章中介绍了视图组件,这是 ASP.NET Core MVC 中的一个新特性,它取代了以前 MVC 版本中的子 action 功能。我演示了如何创建 POCO 视图组件以及如何使用 ViewComponent 基类,并向您展示了视图组件可以产生的三种不同类型的结果,包括选择包含在父视图中的分部视图。在本章结束时,我演示了如何向控制器类中添加视图组件功能,以减少代码重复和简化应用程序。在下一章中,我将介绍标签助手,它用于转换视图中的 HTML 元素。

;

© 2018 - IOT小分队文章发布系统 v0.3